/*
 * pixiv_down - CLI-based downloading tool for https://www.pixiv.net.
 * Copyright (C) 2024  Mio
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, version 3 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
module app.cmds.prune;

import pd.configuration: Config;
import pd.pixiv;

public void displayPruneHelp()
{
   import std.stdio : stderr;

   stderr.writefln(
      "pixiv_down prune - Prune previously followed accounts.\n" ~
      "usage: pixiv_down prune [options]\n" ~
      "\n" ~
      "The prune command walks through all existing directories containing\n"~
      "content  from  followed  accounts.  It then checks if you are still\n"~
      "following that account (or if the account still exists).  If either\n"~
      "of these are not true, pixiv_down will prompt to move the directory\n"~
      "to the recycle bin.\n" ~
      "\n" ~
      "Options:\n" ~
      "   -n, --dry-run    \tPrint the directories that would be moved,\n" ~
      "                    \tbut do not actually move them.\n" ~
      "   -q, --quiet      \tDo not prompt to confirm deleting of files.\n" ~
      "   -s, --silent     \tSynonym of --quiet.\n" ~
      "   -h, --help       \tDisplay this help message and exit.\n");
}

public int pruneHandle(string[] args, const ref Config config)
{
   import std.experimental.logger;
   import std.getopt : getopt, GetOptException, GetOptOption = config;
   import std.stdio : stderr;

   Options options;

   try {
      auto helpInformation = getopt(args,
         GetOptOption.bundling,
         "quiet|q|silent|s", &options.quiet,
         "dry-run|n", &options.dry_run);

      if (helpInformation.helpWanted) {
         displayPruneHelp();
         return 0;
      }
   } catch (GetOptException e) {
      stderr.writefln("pixiv_down prune: %s", e.msg);
      stderr.writefln("Run 'pixiv_down help prune' for more information.");
      return 1;
   }

   if (options.dry_run && options.quiet) {
      return 0;
   }

   infof("running `prune` quietly? %s", options.quiet);
   return runPrune(config, options);
}

private:

// TODO: use std.sumtype
struct Result(T)
{
   ErrorKind error;
   T result;
}

enum ErrorKind
{
   None,
   UserNotFound,
   UserNotFollowed,
   PixivError,
   UnknownError
}

struct Options
{
   bool quiet;
   bool dry_run;
}

void reportProgress(long total, long current)
{
   import mlib.term: Term;
   import std.format: format;
   import std.stdio: stdout;

   Term.clearCurrentLine();

   const ratioCompleted = cast(double)current / total;
   const prefix = format!"%d ["(current);
   const suffix = format!"] %3.0f%%"(ratioCompleted * 100);
   const barLength = Term.getColumnCount() - prefix.length - suffix.length;

   stdout.write(prefix);
   foreach(i; 0..barLength) {
      stdout.write(i < (ratioCompleted * barLength) ? '#' : ' ');
   }
   stdout.write(suffix);
   stdout.flush();
}

Result!(User[]) fetchFollowing(bool forPrivate, const ref Config config)
{
   import pd.pixiv : p_fetchFolloing = fetchFollowing;
   import std.experimental.logger;

   User[] users;
   long total;
   const visibility = forPrivate ? "private" : "public";

   // Fetch public accounts.
   long offset = 0;
   do {
      try {
         User[] page = p_fetchFolloing(forPrivate, offset, total, config);
         if (page.length == 0) {
            tracef("early finish fetching %s followed accounts.", visibility);
            break;
         }
         offset += page.length;
         users ~= page;
         reportProgress(total, offset);
      } catch (PixivException e) {
         errorf("Failed to fetch %s following: %s", visibility, e.msg);
         return Result!(User[])(ErrorKind.PixivError);
      } catch (Exception e) {
         errorf("Unknown error when fetching %s following: %s", visibility, e.msg);
         return Result!(User[])(ErrorKind.UnknownError);
      }
   } while (offset < total);

   return Result!(User[])(ErrorKind.None, users);
}

/// adhoc set implementation using assocArray.
class Set(E)
{
   private void[0][E] data;

   void add(E e)
   {
      data.require(e);
   }

   E[] toArray() const
   {
      return data.keys;
   }

   size_t length() const
   {
      return data.length;
   }
}

struct UserPair
{
   string id;
   string displayName;
   bool valid;
}

Set!string findMissingIds(User[] users, string outputDirectory)
{
   import std.algorithm : countUntil, map;
   import std.ascii : isDigit;
   import std.experimental.logger;
   import std.file : SpanMode, dirEntries;
   import std.path : baseName;
   import std.string : split;

   Set!string missingIds = new Set!string();

   auto userIds = users.map!(u => u.id);
   foreach(dir; dirEntries(outputDirectory, SpanMode.shallow)) {
      const bname = baseName(dir);
      if (bname.length <= 0 || false == isDigit(bname[0])) {
         continue;
      }
      const id = bname.split('_')[0];
      if (userIds.countUntil(id) == -1) {
         infof("adding missing ID %s", id);
         missingIds.add(id);
      }
   }
   return missingIds;
}

Set!UserPair retrieveAccountInfo(in Set!string userIds, const ref Config conf)
{
   import app.util: sleep;
   import std.experimental.logger;
   import std.json : JSONException;
   import pd.pixiv;

   Set!UserPair pairs;
   string[] ids = userIds.toArray();

   pairs = new Set!UserPair();

   foreach(index, id; ids) {
      try {
         auto user = fetchUser(id, conf);

         pairs.add(UserPair(id, user.userName, true));
      } catch (JSONException e) {
         // User does not exist.
         pairs.add(UserPair(id, "", false));
      } catch (Exception e) {
         errorf("failed to fetch user ID %s: %s", id, e.msg);
      }
      displayProgress(index, ids.length, "Retrieving account information");
      sleep(2, 4, false);
   }

   return pairs;
}

void displayProgress(ulong through, ulong total, string message = "")
{
   import std.stdio : stderr;

   auto percent = (cast(float)through / total) * 100.0;

   message = (message == "") ? "Progress" : message;

   stderr.writef("\r\033[2K%s: %d/%d (%3.2f%%)", message, through, total,
      percent);
   stderr.flush();
}

int runPrune(const ref Config config, in Options options)
{
   import app.util: sleep;
   import mlib.term;
   import std.stdio: stdout, stderr;

   int success;

   if (options.quiet) {
      auto publicAccts = fetchFollowing(false, config);
      if (publicAccts.error != ErrorKind.None) {
         return 1;
      }
      auto privateAccts = fetchFollowing(true, config);
      if (privateAccts.error != ErrorKind.None) {
         return 1;
      }

      auto users = publicAccts.result ~ privateAccts.result;

      auto missingIds = findMissingIds(users, config.outputDirectory);
      foreach(id; missingIds.toArray()) {
         success |= remove(id, config.outputDirectory, options.dry_run);
      }
      return success;
   }

   stdout.writeln("Retrieving public following account list...");
   Result!(User[]) publicAccts = fetchFollowing(/* forPrivate */ false, config);
   if (publicAccts.error != ErrorKind.None) {
      stderr.writefln("Failed to retrieve public followed accounts: %s", publicAccts.error);
      return 1;
   }
   Term.clearCurrentLine();
   Term.goUpAndClearLine(1);
   stdout.writeln("Fetched public followed accounts.");

   sleep(1, 10, false);

   stdout.writeln("Retrieving private following account list...");
   Result!(User[]) privateAccts = fetchFollowing(/* forPrivate */ true, config);
   if (privateAccts.error != ErrorKind.None) {
      stderr.writefln("Failed to retrieve private followed accounts: %s", privateAccts.error);
   }
   Term.clearCurrentLine();
   Term.goUpAndClearLine(1);
   stdout.writeln("Fetched private followed accounts.");

   User[] users = publicAccts.result ~ privateAccts.result;

   auto missingIds = findMissingIds(users, config.outputDirectory);

   auto missingAccounts = retrieveAccountInfo(missingIds, config);
   Term.goUpAndClearLine(1);

   foreach(account; missingAccounts.toArray()) {
      auto result = removeAccount(account, config, options.dry_run);
      success |= result.success;
      /* Clear last two lines */
      Term.goUpAndClearLine(1, Yes.useStderr);
      Term.goUpAndClearLine(1, Yes.useStderr);
      if (result.accountRemoved && !options.dry_run) {
         if (account.valid) {
            stdout.writefln("Removed directories for %s", account.displayName);
         } else {
            stdout.writefln("Removed directories for ID %s", account.id);
         }
      } else if (result.accountRemoved && options.dry_run) {
         if (account.valid) {
            stdout.writefln("Would have removed directories for %s", account.displayName);
         } else {
            stdout.writefln("Would have removed directories for ID %s", account.id);
         }
      }
   }

   return success;
}

bool prompt(string msg)
{
   import std.stdio : readln, writef;
   import std.string : toLower, strip;

   writef("%s [y/N]: ", msg);
   string res = readln.strip.toLower();
   if (res == "yes" || res == "y") {
      return true;
   }
   return false;
}

struct RemoveAccountReturn
{
   /// Did the process execute successfully.
   bool success;
   /// Were any directories removed?
   bool accountRemoved;
}

RemoveAccountReturn removeAccount(UserPair user, const ref Config config, bool dryRun)
{
   import std.experimental.logger;
   import std.stdio : writefln;

   bool removed = false;
   immutable promptMessage = dryRun ?
      "Would you want to remove their directories?" :
      "Do you want to remove their directories?";

   tracef("checkAndRemove(UserPair(%s, %s, %d))", user.id, user.displayName, user.valid);

   if (user.valid) {
      writefln("Not following %s (ID %s)", user.displayName, user.id);
      if (prompt(promptMessage)) {
         removed = remove(user.id, config.outputDirectory, dryRun) == 0;
      }
      return RemoveAccountReturn(true, removed);
   }

   writefln("User with ID %s has left pixiv.", user.id);
   if (prompt(promptMessage)) {
      removed = remove(user.id, config.outputDirectory, dryRun) == 0;
   }

   return RemoveAccountReturn(true, removed);
}

/// Remove all directories matching the pattern `id_` within the
/// directory *baseDirectory*.
///
/// If *dryRun* is `true`, no directories will be removed.
int remove(string id, string baseDirectory, bool dryRun)
{
   import std.file : SpanMode, dirEntries;
   import mlib.trash : trash;

   immutable pattern = id ~ "_*";

   if (dryRun) {
      return 0;
   }

   foreach(dir; dirEntries(baseDirectory, pattern, SpanMode.shallow)) {
      trash(dir);
   }

   return 0;
}
